import { IConstruct, Construct } from 'constructs';
import { IApplicationListener } from './application-listener';
import { HttpCodeTarget } from './application-load-balancer';
import * as cloudwatch from '../../../aws-cloudwatch';
import * as ec2 from '../../../aws-ec2';
import { Aws, Annotations, Duration, Token } from '../../../core';
import { ApplicationELBMetrics } from '../elasticloadbalancingv2-canned-metrics.generated';
import {
  BaseTargetGroupProps, ITargetGroup, loadBalancerNameFromListenerArn, LoadBalancerTargetProps,
  TargetGroupAttributes, TargetGroupBase, TargetGroupImportProps,
} from '../shared/base-target-group';
import { ApplicationProtocol, ApplicationProtocolVersion, Protocol, TargetType, TargetGroupLoadBalancingAlgorithmType } from '../shared/enums';
import { ImportedTargetGroupBase } from '../shared/imported';
import { determineProtocolAndPort, parseLoadBalancerFullName, parseTargetGroupFullName } from '../shared/util';

/**
 * Properties for defining an Application Target Group
 */
export interface ApplicationTargetGroupProps extends BaseTargetGroupProps {
  /**
   * The protocol used for communication with the target.
   *
   * This is not applicable for Lambda targets.
   *
   * @default - Determined from port if known
   */
  readonly protocol?: ApplicationProtocol;

  /**
   * The protocol version to use
   *
   * @default ApplicationProtocolVersion.HTTP1
   */
  readonly protocolVersion?: ApplicationProtocolVersion;

  /**
   * The port on which the target receives traffic.
   *
   * This is not applicable for Lambda targets.
   *
   * @default - Determined from protocol if known
   */
  readonly port?: number;

  /**
   * The time period during which the load balancer sends a newly registered
   * target a linearly increasing share of the traffic to the target group.
   *
   * The range is 30-900 seconds (15 minutes).
   *
   * @default 0
   */
  readonly slowStart?: Duration;

  /**
   * The stickiness cookie expiration period.
   *
   * Setting this value enables load balancer stickiness.
   *
   * After this period, the cookie is considered stale. The minimum value is
   * 1 second and the maximum value is 7 days (604800 seconds).
   *
   * @default Duration.days(1)
   */
  readonly stickinessCookieDuration?: Duration;

  /**
   * The name of an application-based stickiness cookie.
   *
   * Names that start with the following prefixes are not allowed: AWSALB, AWSALBAPP,
   * and AWSALBTG; they're reserved for use by the load balancer.
   *
   * Note: `stickinessCookieName` parameter depends on the presence of `stickinessCookieDuration` parameter.
   * If `stickinessCookieDuration` is not set, `stickinessCookieName` will be omitted.
   *
   * @default - If `stickinessCookieDuration` is set, a load-balancer generated cookie is used. Otherwise, no stickiness is defined.
   * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/sticky-sessions.html
   */
  readonly stickinessCookieName?: string;

  /**
   * The load balancing algorithm to select targets for routing requests.
   *
   * @default TargetGroupLoadBalancingAlgorithmType.ROUND_ROBIN
   */
  readonly loadBalancingAlgorithmType?: TargetGroupLoadBalancingAlgorithmType;

  /**
   * The targets to add to this target group.
   *
   * Can be `Instance`, `IPAddress`, or any self-registering load balancing
   * target. If you use either `Instance` or `IPAddress` as targets, all
   * target must be of the same type.
   *
   * @default - No targets.
   */
  readonly targets?: IApplicationLoadBalancerTarget[];
}

/**
 * Contains all metrics for a Target Group of a Application Load Balancer.
 */
export interface IApplicationTargetGroupMetrics {
  /**
   * Return the given named metric for this Network Target Group
   *
   * @default Average over 5 minutes
   */
  custom(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric;

  /**
   * The number of IPv6 requests received by the target group
   *
   * @default Sum over 5 minutes
   */
  ipv6RequestCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric;

  /**
   * The number of requests processed over IPv4 and IPv6.
   *
   * This count includes only the requests with a response generated by a target of the load balancer.
   *
   * @default Sum over 5 minutes
   */
  requestCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric;

  /**
   * The number of healthy hosts in the target group
   *
   * @default Average over 5 minutes
   */
  healthyHostCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric;

  /**
   * The number of unhealthy hosts in the target group
   *
   * @default Average over 5 minutes
   */
  unhealthyHostCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric;

  /**
   * The number of HTTP 2xx/3xx/4xx/5xx response codes generated by all targets in this target group.
   *
   * This does not include any response codes generated by the load balancer.
   *
   * @default Sum over 5 minutes
   */
  httpCodeTarget(code: HttpCodeTarget, props?: cloudwatch.MetricOptions): cloudwatch.Metric;

  /**
   * The average number of requests received by each target in a target group.
   *
   * The only valid statistic is Sum. Note that this represents the average not the sum.
   *
   * @default Sum over 5 minutes
   */
  requestCountPerTarget(props?: cloudwatch.MetricOptions): cloudwatch.Metric;

  /**
   * The number of connections that were not successfully established between the load balancer and target.
   *
   * @default Sum over 5 minutes
   */
  targetConnectionErrorCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric;

  /**
   * The time elapsed, in seconds, after the request leaves the load balancer until a response from the target is received.
   *
   * @default Average over 5 minutes
   */
  targetResponseTime(props?: cloudwatch.MetricOptions): cloudwatch.Metric;

  /**
   * The number of TLS connections initiated by the load balancer that did not establish a session with the target.
   *
   * Possible causes include a mismatch of ciphers or protocols.
   *
   * @default Sum over 5 minutes
   */
  targetTLSNegotiationErrorCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric;
}

/**
 * The metrics for a Application Load Balancer.
 */
class ApplicationTargetGroupMetrics implements IApplicationTargetGroupMetrics {
  private readonly scope: Construct;
  private readonly loadBalancerFullName: string;
  private readonly targetGroupFullName: string;

  public constructor(scope: Construct, targetGroupFullName: string, loadBalancerFullName: string) {
    this.scope = scope;
    this.targetGroupFullName = targetGroupFullName;
    this.loadBalancerFullName = loadBalancerFullName;
  }

  public custom(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric {
    return new cloudwatch.Metric({
      namespace: 'AWS/ApplicationELB',
      metricName,
      dimensionsMap: {
        TargetGroup: this.targetGroupFullName,
        LoadBalancer: this.loadBalancerFullName,
      },
      ...props,
    }).attachTo(this.scope);
  }

  public ipv6RequestCount(props?: cloudwatch.MetricOptions) {
    return this.cannedMetric(ApplicationELBMetrics.iPv6RequestCountSum, props);
  }

  public requestCount(props?: cloudwatch.MetricOptions) {
    return this.cannedMetric(ApplicationELBMetrics.requestCountSum, props);
  }

  public healthyHostCount(props?: cloudwatch.MetricOptions) {
    return this.custom('HealthyHostCount', {
      statistic: 'Average',
      ...props,
    });
  }

  public unhealthyHostCount(props?: cloudwatch.MetricOptions) {
    return this.custom('UnHealthyHostCount', {
      statistic: 'Average',
      ...props,
    });
  }

  public httpCodeTarget(code: HttpCodeTarget, props?: cloudwatch.MetricOptions) {
    return this.custom(code, {
      statistic: 'Sum',
      ...props,
    });
  }

  public requestCountPerTarget(props?: cloudwatch.MetricOptions) {
    return this.custom('RequestCountPerTarget', {
      statistic: 'Sum',
      ...props,
    });
  }

  public targetConnectionErrorCount(props?: cloudwatch.MetricOptions) {
    return this.custom('TargetConnectionErrorCount', {
      statistic: 'Sum',
      ...props,
    });
  }

  public targetResponseTime(props?: cloudwatch.MetricOptions) {
    return this.custom('TargetResponseTime', {
      statistic: 'Average',
      ...props,
    });
  }

  public targetTLSNegotiationErrorCount(props?: cloudwatch.MetricOptions) {
    return this.custom('TargetTLSNegotiationErrorCount', {
      statistic: 'Sum',
      ...props,
    });
  }

  private cannedMetric(
    fn: (dims: { LoadBalancer: string; TargetGroup: string }) => cloudwatch.MetricProps,
    props?: cloudwatch.MetricOptions): cloudwatch.Metric {
    return new cloudwatch.Metric({
      ...fn({
        LoadBalancer: this.loadBalancerFullName,
        TargetGroup: this.targetGroupFullName,
      }),
      ...props,
    }).attachTo(this.scope);
  }
}

/**
 * Define an Application Target Group
 */
export class ApplicationTargetGroup extends TargetGroupBase implements IApplicationTargetGroup {
  /**
   * Import an existing target group
   */
  public static fromTargetGroupAttributes(scope: Construct, id: string, attrs: TargetGroupAttributes): IApplicationTargetGroup {
    return new ImportedApplicationTargetGroup(scope, id, attrs);
  }

  /**
   * Import an existing target group
   *
   * @deprecated Use `fromTargetGroupAttributes` instead
   */
  public static import(scope: Construct, id: string, props: TargetGroupImportProps): IApplicationTargetGroup {
    return ApplicationTargetGroup.fromTargetGroupAttributes(scope, id, props);
  }

  private readonly connectableMembers: ConnectableMember[];
  private readonly listeners: IApplicationListener[];
  private readonly protocol?: ApplicationProtocol;
  private readonly port?: number;
  private _metrics?: IApplicationTargetGroupMetrics;

  constructor(scope: Construct, id: string, props: ApplicationTargetGroupProps = {}) {
    const [protocol, port] = determineProtocolAndPort(props.protocol, props.port);
    const { protocolVersion } = props;
    super(scope, id, { ...props }, {
      protocol,
      protocolVersion,
      port,
    });

    this.protocol = protocol;
    this.port = port;

    // this.targetType is lazy
    this.node.addValidation({
      validate: () => {
        if (this.targetType === TargetType.LAMBDA && (this.port || this.protocol)) {
          return ['port/protocol should not be specified for Lambda targets'];
        } else {
          return [];
        }
      },
    });

    this.connectableMembers = [];
    this.listeners = [];

    if (props) {
      if (props.slowStart !== undefined) {
        // 0 is allowed and disables slow start
        if ((props.slowStart.toSeconds() < 30 && props.slowStart.toSeconds() !== 0) || props.slowStart.toSeconds() > 900) {
          throw new Error('Slow start duration value must be between 30 and 900 seconds, or 0 to disable slow start.');
        }
        this.setAttribute('slow_start.duration_seconds', props.slowStart.toSeconds().toString());
      }

      if (props.stickinessCookieDuration) {
        this.enableCookieStickiness(props.stickinessCookieDuration, props.stickinessCookieName);
      } else {
        this.setAttribute('stickiness.enabled', 'false');
      }

      if (props.loadBalancingAlgorithmType) {
        this.setAttribute('load_balancing.algorithm.type', props.loadBalancingAlgorithmType);
      }
      this.addTarget(...(props.targets || []));
    }
  }

  public get metrics(): IApplicationTargetGroupMetrics {
    if (!this._metrics) {
      this._metrics = new ApplicationTargetGroupMetrics(this, this.targetGroupFullName, this.firstLoadBalancerFullName);
    }
    return this._metrics;
  }

  /**
   * Add a load balancing target to this target group
   */
  public addTarget(...targets: IApplicationLoadBalancerTarget[]) {
    for (const target of targets) {
      const result = target.attachToApplicationTargetGroup(this);
      this.addLoadBalancerTarget(result);
    }

    if (this.targetType === TargetType.LAMBDA) {
      this.setAttribute('stickiness.enabled', undefined);
    }
  }

  /**
   * Enable sticky routing via a cookie to members of this target group.
   *
   * Note: If the `cookieName` parameter is set, application-based stickiness will be applied,
   * otherwise it defaults to duration-based stickiness attributes (`lb_cookie`).
   *
   * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/sticky-sessions.html
   */
  public enableCookieStickiness(duration: Duration, cookieName?: string) {
    if (duration.toSeconds() < 1 || duration.toSeconds() > 604800) {
      throw new Error('Stickiness cookie duration value must be between 1 second and 7 days (604800 seconds).');
    }
    if (cookieName !== undefined) {
      if (!Token.isUnresolved(cookieName) && (cookieName.startsWith('AWSALB') || cookieName.startsWith('AWSALBAPP') || cookieName.startsWith('AWSALBTG'))) {
        throw new Error('App cookie names that start with the following prefixes are not allowed: AWSALB, AWSALBAPP, and AWSALBTG; they\'re reserved for use by the load balancer.');
      }
      if (cookieName === '') {
        throw new Error('App cookie name cannot be an empty string.');
      }
    }
    this.setAttribute('stickiness.enabled', 'true');
    if (cookieName) {
      this.setAttribute('stickiness.type', 'app_cookie');
      this.setAttribute('stickiness.app_cookie.cookie_name', cookieName);
      this.setAttribute('stickiness.app_cookie.duration_seconds', duration.toSeconds().toString());
    } else {
      this.setAttribute('stickiness.type', 'lb_cookie');
      this.setAttribute('stickiness.lb_cookie.duration_seconds', duration.toSeconds().toString());
    }
  }

  /**
   * Register a connectable as a member of this target group.
   *
   * Don't call this directly. It will be called by load balancing targets.
   */
  public registerConnectable(connectable: ec2.IConnectable, portRange?: ec2.Port) {
    portRange = portRange || ec2.Port.tcp(this.defaultPort);

    // Notify all listeners that we already know about of this new connectable.
    // Then remember for new listeners that might get added later.
    this.connectableMembers.push({ connectable, portRange });
    for (const listener of this.listeners) {
      listener.registerConnectable(connectable, portRange);
    }
  }

  /**
   * Register a listener that is load balancing to this target group.
   *
   * Don't call this directly. It will be called by listeners.
   */
  public registerListener(listener: IApplicationListener, associatingConstruct?: IConstruct) {
    // Notify this listener of all connectables that we know about.
    // Then remember for new connectables that might get added later.
    for (const member of this.connectableMembers) {
      listener.registerConnectable(member.connectable, member.portRange);
    }
    this.listeners.push(listener);
    this.loadBalancerAttachedDependencies.add(associatingConstruct ?? listener);
  }

  /**
   * Full name of first load balancer
   */
  public get firstLoadBalancerFullName(): string {
    if (this.listeners.length === 0) {
      throw new Error('The TargetGroup needs to be attached to a LoadBalancer before you can call this method');
    }
    return loadBalancerNameFromListenerArn(this.listeners[0].listenerArn);
  }

  /**
   * Return the given named metric for this Application Load Balancer Target Group
   *
   * Returns the metric for this target group from the point of view of the first
   * load balancer load balancing to it. If you have multiple load balancers load
   * sending traffic to the same target group, you will have to override the dimensions
   * on this metric.
   *
   * @default Average over 5 minutes
   */
  public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric {
    return this.metrics.custom(metricName, props);
  }

  /**
   * The number of IPv6 requests received by the target group
   *
   * @default Sum over 5 minutes
   * @deprecated Use ``ApplicationTargetGroup.metrics.ipv6RequestCount`` instead
   */
  public metricIpv6RequestCount(props?: cloudwatch.MetricOptions) {
    return this.metrics.ipv6RequestCount(props);
  }

  /**
   * The number of requests processed over IPv4 and IPv6.
   *
   * This count includes only the requests with a response generated by a target of the load balancer.
   *
   * @default Sum over 5 minutes
   * @deprecated Use ``ApplicationTargetGroup.metrics.requestCount`` instead
   */
  public metricRequestCount(props?: cloudwatch.MetricOptions) {
    return this.metrics.requestCount(props);
  }

  /**
   * The number of healthy hosts in the target group
   *
   * @default Average over 5 minutes
   * @deprecated Use ``ApplicationTargetGroup.metrics.healthyHostCount`` instead
   */
  public metricHealthyHostCount(props?: cloudwatch.MetricOptions) {
    return this.metrics.healthyHostCount(props);
  }

  /**
   * The number of unhealthy hosts in the target group
   *
   * @default Average over 5 minutes
   * @deprecated Use ``ApplicationTargetGroup.metrics.unhealthyHostCount`` instead
   */
  public metricUnhealthyHostCount(props?: cloudwatch.MetricOptions) {
    return this.metrics.unhealthyHostCount(props);
  }

  /**
   * The number of HTTP 2xx/3xx/4xx/5xx response codes generated by all targets in this target group.
   *
   * This does not include any response codes generated by the load balancer.
   *
   * @default Sum over 5 minutes
   * @deprecated Use ``ApplicationTargetGroup.metrics.httpCodeTarget`` instead
   */
  public metricHttpCodeTarget(code: HttpCodeTarget, props?: cloudwatch.MetricOptions) {
    return this.metrics.httpCodeTarget(code, props);
  }

  /**
   * The average number of requests received by each target in a target group.
   *
   * The only valid statistic is Sum. Note that this represents the average not the sum.
   *
   * @default Sum over 5 minutes
   * @deprecated Use `ApplicationTargetGroup.metrics.requestCountPerTarget` instead
   */
  public metricRequestCountPerTarget(props?: cloudwatch.MetricOptions) {
    return this.metrics.requestCountPerTarget(props);
  }

  /**
   * The number of connections that were not successfully established between the load balancer and target.
   *
   * @default Sum over 5 minutes
   * @deprecated Use ``ApplicationTargetGroup.metrics.targetConnectionErrorCount`` instead
   */
  public metricTargetConnectionErrorCount(props?: cloudwatch.MetricOptions) {
    return this.metrics.targetConnectionErrorCount(props);
  }

  /**
   * The time elapsed, in seconds, after the request leaves the load balancer until a response from the target is received.
   *
   * @default Average over 5 minutes
   * @deprecated Use ``ApplicationTargetGroup.metrics.targetResponseTime`` instead
   */
  public metricTargetResponseTime(props?: cloudwatch.MetricOptions) {
    return this.metrics.targetResponseTime(props);
  }

  /**
   * The number of TLS connections initiated by the load balancer that did not establish a session with the target.
   *
   * Possible causes include a mismatch of ciphers or protocols.
   *
   * @default Sum over 5 minutes
   * @deprecated Use ``ApplicationTargetGroup.metrics.tlsNegotiationErrorCount`` instead
   */
  public metricTargetTLSNegotiationErrorCount(props?: cloudwatch.MetricOptions) {
    return this.metrics.targetTLSNegotiationErrorCount(props);
  }

  protected validateTargetGroup(): string[] {
    const ret = super.validateTargetGroup();

    if (this.targetType !== undefined && this.targetType !== TargetType.LAMBDA
      && (this.protocol === undefined || this.port === undefined)) {
      ret.push('At least one of \'port\' or \'protocol\' is required for a non-Lambda TargetGroup');
    }

    if (this.healthCheck) {
      if (this.healthCheck.interval && this.healthCheck.timeout &&
        this.healthCheck.interval.toMilliseconds() <= this.healthCheck.timeout.toMilliseconds()) {
        ret.push(`Healthcheck interval ${this.healthCheck.interval.toHumanString()} must be greater than the timeout ${this.healthCheck.timeout.toHumanString()}`);
      }

      if (this.healthCheck.protocol) {
        if (!ALB_HEALTH_CHECK_PROTOCOLS.includes(this.healthCheck.protocol)) {
          ret.push([
            `Health check protocol '${this.healthCheck.protocol}' is not supported. `,
            `Must be one of [${ALB_HEALTH_CHECK_PROTOCOLS.join(', ')}]`,
          ].join(''));
        }
      }
    }

    return ret;
  }
}

/**
 * A connectable member of a target group
 */
interface ConnectableMember {
  /**
   * The connectable member
   */
  connectable: ec2.IConnectable;

  /**
   * The port (range) the member is listening on
   */
  portRange: ec2.Port;
}

/**
 * A Target Group for Application Load Balancers
 */
export interface IApplicationTargetGroup extends ITargetGroup {
  /**
   * All metrics available for this target group.
   */
  readonly metrics: IApplicationTargetGroupMetrics;

  /**
   * Register a listener that is load balancing to this target group.
   *
   * Don't call this directly. It will be called by listeners.
   */
  registerListener(listener: IApplicationListener, associatingConstruct?: IConstruct): void;

  /**
   * Register a connectable as a member of this target group.
   *
   * Don't call this directly. It will be called by load balancing targets.
   */
  registerConnectable(connectable: ec2.IConnectable, portRange?: ec2.Port): void;

  /**
   * Add a load balancing target to this target group
   */
  addTarget(...targets: IApplicationLoadBalancerTarget[]): void;
}

/**
 * An imported application target group
 */
class ImportedApplicationTargetGroup extends ImportedTargetGroupBase implements IApplicationTargetGroup {
  private readonly _metrics?: IApplicationTargetGroupMetrics;

  public constructor(scope: Construct, id: string, props: TargetGroupAttributes) {
    super(scope, id, props);
    if (this.loadBalancerArns != Aws.NO_VALUE) {
      const targetGroupFullName = parseTargetGroupFullName(this.targetGroupArn);
      const firstLoadBalancerFullName = parseLoadBalancerFullName(this.loadBalancerArns);
      this._metrics = new ApplicationTargetGroupMetrics(this, targetGroupFullName, firstLoadBalancerFullName);
    }
  }

  public registerListener(_listener: IApplicationListener, _associatingConstruct?: IConstruct) {
    // Nothing to do, we know nothing of our members
    Annotations.of(this).addWarningV2('@aws-cdk/aws-elbv2:albTargetGroupCannotRegisterListener', 'Cannot register listener on imported target group -- security groups might need to be updated manually');
  }

  public registerConnectable(_connectable: ec2.IConnectable, _portRange?: ec2.Port | undefined): void {
    Annotations.of(this).addWarningV2('@aws-cdk/aws-elbv2:albTargetGroupCannotRegisterConnectable', 'Cannot register connectable on imported target group -- security groups might need to be updated manually');
  }

  public addTarget(...targets: IApplicationLoadBalancerTarget[]) {
    for (const target of targets) {
      const result = target.attachToApplicationTargetGroup(this);

      if (result.targetJson !== undefined) {
        throw new Error('Cannot add a non-self registering target to an imported TargetGroup. Create a new TargetGroup instead.');
      }
    }
  }

  public get metrics(): IApplicationTargetGroupMetrics {
    if (!this._metrics) {
      throw new Error(
        'The imported ApplicationTargetGroup needs the associated ApplicationBalancer to be able to provide metrics. ' +
        'Please specify the ARN value when importing it.');
    }
    return this._metrics;
  }
}

/**
 * Interface for constructs that can be targets of an application load balancer
 */
export interface IApplicationLoadBalancerTarget {
  /**
   * Attach load-balanced target to a TargetGroup
   *
   * May return JSON to directly add to the [Targets] list, or return undefined
   * if the target will register itself with the load balancer.
   */
  attachToApplicationTargetGroup(targetGroup: IApplicationTargetGroup): LoadBalancerTargetProps;
}

const ALB_HEALTH_CHECK_PROTOCOLS = [Protocol.HTTP, Protocol.HTTPS];
